Kernel perf: fix opt-level, stop allocating — and draw a fractal - #91
Merged
Conversation
Two changes and one negative result. **opt-level.** The crate was scaffolded `opt-level = "z"`, which suppresses the inlining a dispatch loop lives on. Moving to 3 (with codegen-units = 1) is worth 3.3x on the call-heavy benchmark and 1.5x on the loop benchmark, measured back to back on the same machine. This dominates everything else here; the image is small either way. **Allocation.** The interpreter allocated four times per call — two `Rc::new(Vec::new())` for empty capture lists, the operand vector, and the register file — plus one per jump for the block params. Now: one shared empty-captures Rc, a pool of retired register files, operands read into an inline buffer, and params borrowed out of the module instead of cloned. Tail calls recycle their own outgoing register file, which is what a tail-recursive loop needs since it never returns through a frame. That took the benchmark from 89,858 allocations to 47, and **bought no measurable time.** Interleaved A/B runs put the two builds inside each other's noise. The PR that landed the kernel claimed allocation was the bottleneck; that was wrong. The change is kept because interpreting in near-constant memory is worth having in a kernel with no OOM killer behind the heap — not as a speedup. A slab allocator, which is where this was heading, is not worth building: there is nothing left for it to allocate, and even at 90k allocations the first-fit list was not costing measurable time. Adds the harness this was measured with: boot/bench.oo (calls, sequences), boot/loop.oo (tail recursion, so no frame pushes), exact op counts from the VM's dispatch counter, exact allocation counts from the global allocator, and bench.sh to take a minimum over several boots because wall-clock inside an emulator on a shared machine is only ever biased upward. Remaining: ~500 ns/op against ~7 ns/op for a minimal native dispatch loop on the same emulator. loop.oo and bench.oo cost about the same per op, so the gap is per-op dispatch, not calling. Chasing it needs an idle machine and a profiler rather than more guessing. `make check` still passes: same output on the host and on bare metal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
boot/mandel.oo renders the Mandelbrot set in ASCII, and the unikernel runs it after init. Every float op, closure application and string concat goes through the bare-metal interpreter — no libm, no libc, no OS — and the output is byte-identical to the host, which `make check` now enforces alongside init. So it is gratuitous, but it is also the float path's parity check in disguise. Adds Float/Int intrinsics to the kernel builtins on the way (int<->float conversion, string parse via core's parser to match the host exactly). Also surfaced a small language gap: the checker types `float` as Str -> Float even though the runtime accepts Int, so int->float needs the `[float [str x]]` detour. Worth fixing in the checker; noted, not done here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #90. Two changes, one negative result, and one gratuitous thing.
opt-leveldominates everythingThe crate was scaffolded with
opt-level = "z", which suppresses the inlininga dispatch loop lives on. Moving to
3(withcodegen-units = 1) is worth3.3× on the call-heavy benchmark and 1.5× on the loop benchmark, measured
back to back on the same machine. Nothing else here came close.
Allocation was never the bottleneck
The interpreter allocated four times per call — two
Rc::new(Vec::new())forempty capture lists, the operand vector, and the register file — plus one per
jump for the block params. Now: one shared empty-captures
Rc, a pool ofretired register files, operands read into an inline buffer, and params
borrowed out of the module instead of cloned. Tail calls recycle their own
outgoing register file, which a tail-recursive loop needs since it never
returns through a frame.
That took the benchmark from 89,858 allocations to 47 and bought no
measurable time. Interleaved A/B runs put the two builds inside each other's
noise. #90's description claimed allocation was the bottleneck; that was wrong,
and the README and design doc now say so.
The change is kept on different grounds: interpreting in near-constant memory
is worth having in a kernel with no OOM killer behind the heap. It is not a
speedup.
A slab allocator is therefore not built. There is nothing left for it to
allocate, and even at 90k allocations the first-fit list was not costing
measurable time. Building it would add complexity to a path the measurements
say does not matter.
The kernel draws a fractal
boot/mandel.oorenders the Mandelbrot set in ASCII and the unikernel runs itafter init. Every float op, closure application and string concat goes through
the bare-metal interpreter — no libm, no libc, no OS — and the output is
byte-identical to the host.
make checknow enforces that alongside init, sothe fractal doubles as the float path's parity test.
Adds
Float/Intintrinsics to the kernel builtins on the way (string parsevia
core's parser, deliberately, to match the host exactly).Reviewer notes
boot/bench.oo(calls, sequences),boot/loop.oo(tail recursion →
Recur, so no frame pushes), exact op counts from theVM's dispatch counter, exact allocation counts from the global allocator,
and
bench.shtaking a minimum over N boots. Op and alloc counts aredeterministic; wall-clock inside an emulator on a shared machine is only
ever biased upward, so treat any timing delta under ~30% as noise unless it
reproduces under interleaving. (Another project was pegging the machine at
1200% CPU during this work; identical code ranged 510–1825 ns/op.)
loop on the same emulator.
loop.ooandbench.oocost about the same perop, so it is per-op dispatch, not calling. Chasing it needs an idle machine
and a profiler.
floatasStr → Floateven though the runtime accepts
Int, so int→float needs the[float [str x]]detour. Worth a checker fix; noted, not done here.bugs hide.
make checkstill passes (forwarding, abort, non-tail resume allidentical to host), and
recyclerefuses zero-capacity vectors — parkingone would evict a real buffer and hand the next call something it has to
grow from nothing (which briefly regressed
benchto 11k allocs before Icaught it).
cargo test --workspacegreen;unikernel_boottest passes locally and skips on CI asdesigned.
🤖 Generated with Claude Code